【AWS CDK】cdk-nagのテストをGitHub Actionsに追加して、セキュリティチェックを自動化する

【AWS CDK】cdk-nagのテストをGitHub Actionsに追加して、セキュリティチェックを自動化する

Clock Icon2024.12.30

cdk-nag は Constructs がルールに準拠しているかを検証してくれるツールで、多くのルールセットがあります。

まだ cdk-nag について知らないという方は、以下を参照してください。
https://dev.classmethod.jp/articles/aws-cdk-compliance-check-with-cdk-nag/

上記の記事では cdk-nag を cdk synth したタイミングで実行し、エラーを確認していました。
これでも良いのですが、できればテストに組み込んでエラーがある場合は修正するフローにしたいですよね。

今回は cdk-nag をアサーションテストに組み込み、CI として実行するところまで確認してみます。

cdk-nagをテストで実行するメリット

cdk-nag をテストに追加し自動化することで、以下のようなメリットがあります。

  • テスト自動化による継続的なセキュリティチェック
  • 開発段階からセキュアな環境構築が可能
  • チーム全体でセキュリティ基準の統一が測れる

導入自体それほどハードルが高くないため、セキュリティに不安がある場合ほど導入するメリットが大きいです。
これらをレビューや個別にテストコードを書いて対応するのは大変なので、テストによる自動化をしていきましょう。

テストコード実装

以下の AWS ブログの中にアサーションテストを用いたユニットテストの実装例があったので、これを参考に導入してみます。
https://aws.amazon.com/jp/blogs/news/manage-application-security-and-compliance-with-the-aws-cloud-development-kit-and-cdk-nag/

前提

リソースは S3 バケットを作成しただけのものを用意します。

lib/cdk-nag-test-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { NagSuppressions } from "cdk-nag";
export class CdkNagTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const bucket = new Bucket(this, "Bucket", {});
  }
}

テストコードを追加する

AWS ブログにあった内容で実装してみます。

test/cdk-nag-test.test.ts
import { Annotations, Match } from "aws-cdk-lib/assertions";
import { App, Aspects, Stack } from "aws-cdk-lib";
import { AwsSolutionsChecks } from "cdk-nag";
import { CdkNagTestStack } from "../lib/cdk-nag-test-stack";

describe("cdk-nag AwsSolutions Pack", () => {
  let stack: Stack;
  let app: App;
  // In this case we can use beforeAll() over beforeEach() since our tests
  // do not modify the state of the application
  beforeAll(() => {
    // GIVEN
    app = new App();
    stack = new CdkNagTestStack(app, "test");

    // WHEN
    Aspects.of(stack).add(new AwsSolutionsChecks());
  });

  // THEN
  test("No unsuppressed Warnings", () => {
    const warnings = Annotations.fromStack(stack).findWarning(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*"),
    );
    expect(warnings).toHaveLength(0);
  });

  test("No unsuppressed Errors", () => {
    const errors = Annotations.fromStack(stack).findError(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*"),
    );
    expect(errors).toHaveLength(0);
  });
});

cdk-nag の実行結果からWarningErrorそれぞれ取得して、0 件であることをテストしています。今回はAwsSolutionsのルールセットを利用しているため、取得対象もAwsSolutionsのものだけになっています。

もしPCI DSS 3.2.1等別のルールセットの場合は、適宜修正してください。

これでnpm run testを実行してみます。
スクリーンショット 2024-12-30 午前8.25.31.png

テキストでの確認はこちら
npm run test 

> cdk-nag-test@0.1.0 test
> jest

 FAIL  test/cdk-nag-test.test.ts
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (196 ms)
    ✕ No unsuppressed Errors (12 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    expect(received).toHaveLength(expected)

    Expected length: 0
    Received length: 2
    Received array:  [{"entry": {"data": "AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    ", "trace": ["Annotations.addMessage (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/annotations.js:1:1710)", "Annotations.addError (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/annotations.js:1:1202)", "AnnotationLogger.onNonCompliance (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-logger.ts:142:37)", "/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-pack.ts:185:17", "Array.forEach (<anonymous>)", "AwsSolutionsChecks.applyRule (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-pack.ts:184:26)", "AwsSolutionsChecks.checkStorage (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/packs/aws-solutions.ts:401:10)", "AwsSolutionsChecks.visit (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/packs/aws-solutions.ts:200:12)", "recurse (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2:3976)", "recurse (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2:4160)", …], "type": "aws:cdk:error"}, "id": "/test/Bucket/Resource", "level": "error"}, {"entry": {"data": "AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
    ", "trace": ["Annotations.addMessage (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/annotations.js:1:1710)", "Annotations.addError (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/annotations.js:1:1202)", "AnnotationLogger.onNonCompliance (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-logger.ts:142:37)", "/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-pack.ts:185:17", "Array.forEach (<anonymous>)", "AwsSolutionsChecks.applyRule (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/nag-pack.ts:184:26)", "AwsSolutionsChecks.checkStorage (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/packs/aws-solutions.ts:428:10)", "AwsSolutionsChecks.visit (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/cdk-nag/src/packs/aws-solutions.ts:200:12)", "recurse (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2:3976)", "recurse (/Users/suzuki.jun/Documents/github_blog/blog/cdk-nag-test/node_modules/aws-cdk-lib/core/lib/private/synthesis.js:2:4160)", …], "type": "aws:cdk:error"}, "id": "/test/Bucket/Resource", "level": "error"}]

      32 |       Match.stringLikeRegexp("AwsSolutions-.*"),
      33 |     );
    > 34 |     expect(errors).toHaveLength(0);
         |                    ^
      35 |   });
      36 | });
      37 |

      at Object.<anonymous> (test/cdk-nag-test.test.ts:34:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        3.586 s, estimated 4 s
Ran all test suites.

2 件のエラーが検知されたことを確認できました。
少し分かりづらいですが、AwsSolutions-S1AwsSolutions-S10が検出されています。

エラーメッセージの整形

これでも cdk-nag によるエラーが発生していることは分かるのですが、エラーメッセージが長文で分かりにくいです。

整形したいなと思っていたところ、以下の素晴らしいブログがあったのでこちらの実装をお借りしました。非常に分かりやすく解説されているので、ぜひ一度読んでみてください。
https://qiita.com/y_matsuo_/items/6ef17964ff3c557f6d1e

一部 linter、formatter による修正がありますが、ほぼ同じ内容です。

test/cdk-nag-test.test.ts
import { Annotations, Match } from "aws-cdk-lib/assertions";
import { App, Aspects, Stack } from "aws-cdk-lib";
import { AwsSolutionsChecks } from "cdk-nag";
import { CdkNagTestStack } from "../lib/cdk-nag-test-stack";
import { SynthesisMessage } from "aws-cdk-lib/cx-api/lib/metadata";
describe("cdk-nag AwsSolutions Pack", () => {
  let stack: Stack;
  let app: App;

  beforeEach(() => {
    // GIVEN
    app = new App();
    stack = new CdkNagTestStack(app, "test");

    // WHEN
    Aspects.of(stack).add(new AwsSolutionsChecks());
  });

  // THEN
  test("No unsuppressed Warnings", () => {
    const warnings = Annotations.fromStack(stack).findWarning(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*"),
    );
    try {
      expect(warnings).toHaveLength(0);
    } catch (e) {
      throw new Error(createCdkNagLog(warnings));
    }
  });

  test("No unsuppressed Errors", () => {
    const errors = Annotations.fromStack(stack).findError(
      "*",
      Match.stringLikeRegexp("AwsSolutions-.*"),
    );
    try {
      expect(errors).toHaveLength(0);
    } catch (e) {
      throw new Error(createCdkNagLog(errors));
    }
  });
});

function createCdkNagLog(messages: SynthesisMessage[]): string {
  let log = "";

  for (const message of messages) {
    switch (message.level) {
      case "info":
        log += "\u001b[34m"; // blue
        break;
      case "warning":
        log += "\u001b[33m"; // yellow
        break;
      case "error":
        log += "\u001b[31m"; // red
        break;
      default:
        log += "\u001b[30m"; // black
        break;
    }
    log += `[${message.level} at ${message.id}] ${message.entry.data as string}\u001b[0m`;
  }

  return log;
}

修正が完了したら、npm run testを実行してみます。

スクリーンショット 2024-12-30 午前8.22.42.png

テキストでの確認はこちら
npm run test

> cdk-nag-test@0.1.0 test
> jest

 FAIL  test/cdk-nag-test.test.ts
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (289 ms)
    ✕ No unsuppressed Errors (16 ms)

  ● cdk-nag AwsSolutions Pack › No unsuppressed Errors

    [error at /test/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
    [error at /test/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.

      38 |       expect(errors).toHaveLength(0);
      39 |     } catch (e) {
    > 40 |       throw new Error(createCdkNagLog(errors));
         |             ^
      41 |     }
      42 |   });
      43 | });

      at Object.<anonymous> (test/cdk-nag-test.test.ts:40:13)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        3.55 s, estimated 4 s
Ran all test suites.

これでエラーが発生している件数と内容が分かりやすくなりました。これであればうまく運用できそうです。

GitHub Actions上で動作させる

ローカルで cdk-nag のテスト確認ができたので、GitHub Actions 上で CI として実行してみます。

.github/workflows/cdk-nag-test.ymlのファイルを作成し、テストが実行されるように記述します。
Node.js をセットアップして、依存関係のインストールとテスト実行するだけのシンプルなものです。

.github/workflows/cdk-nag-test.yml
name: CDK Nag Test

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Use Node.js 20
      uses: actions/setup-node@v3
      with:
        node-version: '20'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Run tests
      run: npm test

これで準備ができたので、GitHub 上に Push して動作を確認してみます。
エラーを修正せずに Push したため、同じエラーが発生しました。
スクリーンショット 2024-12-30 午前8.46.39.png

エラーを修正するため、lib/cdk-nag-test-stack.tsを以下のように修正します。

lib/cdk-nag-test-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket } from "aws-cdk-lib/aws-s3";
+import { NagSuppressions } from "cdk-nag";
export class CdkNagTestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
+    NagSuppressions.addStackSuppressions(this, [
+      {
+        id: "AwsSolutions-S1",
+        reason: "This is a demo bucket, so it does not need access logs",
+      },
    ]);
    const bucket = new Bucket(this, "Bucket", {
+      enforceSSL: true,
    });
  }
}

AwsSolutions-S1は抑制し、AwsSolutions-S10は修正しました。

この状態でローカルテストを実行すると、成功することが確認できます。

npm run test 

> cdk-nag-test@0.1.0 test
> jest

 PASS  test/cdk-nag-test.test.ts
  cdk-nag AwsSolutions Pack
    ✓ No unsuppressed Warnings (231 ms)
    ✓ No unsuppressed Errors (19 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.136 s, estimated 4 s
Ran all test suites.

問題なさそうなので、GitHub にもう一度 Push して成功するか確認します。
スクリーンショット 2024-12-30 午前8.54.33.png

無事テストが成功したことを確認できました。

まとめ

cdk-nag を使ったテスト導入と GitHub Actions 上での動作を確認してみました。npm test で実行できるため、CI パイプラインに組み込みやすい点も導入しやすいですね。

CDK では L2 コンストラクトを使っている場合、自動で設定される値がセキュアではないケースが多々あります。cdk-nag をユニットテストで実行することで、セキュアな状態で開発ができるので、ぜひ導入を検討してみてください。

導入コストは小さいですが、既存プロジェクトの場合は多くのエラーが出る可能性もあります。段階的に対応していき、抑制する場合は明確な理由を記載するなど運用ルールを決めていきましょう。

CDK を利用している方は、ぜひ本記事を参考にプロジェクトのセキュリティ強化に取り組んでみてください。

参考

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.